.infobox {
padding: 1em 1em 1em 4em;
background: aliceblue 5px center/3em no-repeat;
color: black;
}8 Веб-скрапинг
Файлы html, как и XML, хранят данные в структурированном виде. Извлечь их позволяет пакет rvest. С его помощью мы добудем архив телеграм-канала Antibarbari HSE. Канал публичный, и Telegram дает возможность скачать архив в формате html при помощи кнопки export (эта функция может быть недоступна на MacOS, в этом случае стоит попробовать Telegram Lite).
Эта глава опирается в основом на второе издание книги R for Data Science Хадли Викхема.
8.1 Структура html
Документы html (HyperText Markup Language) имеют ирархическую структуру, состоящую из элементов. В каждом элементе есть открывающий тег (<tag>), опциональные атрибуты (id='first') и закрывающий тег (</tag>). Все, что находится между открывающим и закрывающим тегом, называется содержанием элемента.
Важнейшие теги, о которых стоит знать:
- <html> (есть всегда), с двумя детьми (дочерними элементами): <head> и <body>
- элементы, отвечающие за структуру: <h1> (заголовок), <section>, <p> (параграф), <ol> (упорядоченный список)
- элементы, отвечающие за оформление: <b> (bold), <i> (italics), <a> (ссылка)
Чтобы увидеть структуру веб-страницы, надо нажать правую кнопку мыши и выбрать View Source (это работает и для тех html, которые хранятся у вас на компьютере).
8.2 Каскадные таблицы стилей
У тегов могут быть именованные атрибуты; важнейшие из них – это id и class, которые в сочетании с CSS контролируют внешний вид страницы.
Пример css-правила (такие инфобоксы использованы в предыдущей версии курса):
Проще говоря, это инструкция, что делать с тем или иным элементом. Каждое правило CSS имеет две основные части — селектор и блок объявлений. Селектор, расположенный в левой части правила до знака {, определяет, на какие части документа (возможно, специально обозначенные) распространяется правило. Блок объявлений располагается в правой части правила. Он помещается в фигурные скобки, и, в свою очередь, состоит из одного или более объявлений, разделённых знаком «;».
Селекторы CSS полезны для скрапинга, потому что они помогают вычленить необходимые элементы. Это работает так:
pвыберет все элементы <p>.titleвыберет элементы с классом “title”#titleвыберет все элементы с атрибутом id=‘title’
Важно: если изменится структура страницы, откуда вы скрапили информацию, то и код придется переписывать.
8.3 Чтение html
Чтобы прочесть файл html, используем одноименную функцию.
library(rvest)
antibarbari_files <- list.files("../files/antibarbari_2024-08-18", pattern = "html", full.names = TRUE)Используем пакет purrr, чтобы прочитать сразу три файла из архива.
library(tidyverse)
antibarbari_archive <- map(antibarbari_files, read_html)8.4 Парсинг html: отдельные элементы
На следующем этапе важно понять, какие именно элементы нужны. Рассмотрим на примере одного сообщения. Для примера я сохраню этот элемент как небольшой отдельный html; rvest позволяет это сделать (но внутри двойных кавычек должны быть только одинарные):
example_html <- minimal_html("
<div class='message default clearfix' id='message83'>
<div class='pull_left userpic_wrap'>
<div class='userpic userpic2' style='width: 42px; height: 42px'>
<div class='initials' style='line-height: 42px'>
A
</div>
</div>
</div>
<div class='body'>
<div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'>
11:18
</div>
<div class='from_name'>
Antibarbari HSE
</div>
<div class='text'>
Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора. <br><br>«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).<br><a href='http://antibarbari.ru/2022/05/19/digest_1/'>http://antibarbari.ru/2022/05/19/digest_1/</a>
</div>
<div class='signature details'>
Olga Alieva
</div>
</div>
</div>
")Из всего этого мне может быть интересно id сообщения (\<div class='message default clearfix' id='message83'\>), текст сообщения (\<div class='text'\>), дата публикации (\<div class='pull_right date details' title='19.05.2022 11:18:07 UTC+03:00'\>), а также, если указан, автор сообщения (\<div class='signature details'\>). Извлекаем текст (для этого рекомендуется использовать функцию html_text2()):
example_html |>
html_element(".text") |>
html_text2()[1] "Этот пост открывает серию переложений из «Дайджеста платоновских идиом» Джеймса Ридделла (1823–1866), английского филолога-классика, чей научный путь был связан с Оксфордским университетом. По приглашению Бенджамина Джоветта он должен был подготовить к изданию «Апологию», «Критон», «Федон» и «Пир». Однако из этих четырех текстов вышла лишь «Апология» с предисловием и приложением в виде «Дайджеста» (ссылка) — уже после смерти автора.\n\n«Дайджест» содержит 326 параграфов, посвященных грамматическим, синтаксическим и риторическим особенностям языка Платона. Знакомство с этим теоретическим материалом позволяет лучше почувствовать уникальный стиль философа и добиться большей точности при переводе. Ссылки на «Дайджест» могут быть уместны и в учебных комментариях к диалогам Платона. Предлагаемая здесь первая часть «Дайджеста» содержит «идиомы имен» и «идиомы артикля» (§§ 1–39).\nhttp://antibarbari.ru/2022/05/19/digest_1/"
В классе signature details есть пробел, достаточно на его месте поставить точку:
example_html |>
html_element(".signature.details") |>
html_text2()[1] "Olga Alieva"
Осталось добыть дату и message id:
example_html |>
html_element(".pull_right.date.details") |>
html_attr("title")[1] "19.05.2022 11:18:07 UTC+03:00"
example_html |>
html_element("div") |>
html_attr("id")[1] "message83"
Теперь мы можем сохранить все нужные нам данные в таблицу.
tibble(id = example_html |>
html_element("div") |>
html_attr("id"),
date = example_html |>
html_element(".pull_right.date.details") |>
html_attr("title"),
signature = example_html |>
html_element(".signature.details") |>
html_text2(),
text = example_html |>
html_element(".text") |>
html_text2()
)8.5 Парсинг html: вложенные элементы
До сих пор наша задача упрощалась тем, что мы имели дело с игрушечным html для единственного сообщения. В настоящем html тег div повторяется на разных уровнях, и нам надо извлечь только такие div, которым соответствует определенный класс. Также не будем забывать, что архив выгрузился в виде трех html-файлов, так что понадобится наше знание итераций в purrr. Пока пробуем на одном из них:
archive_1 <- antibarbari_archive[[1]]
archive_1 |>
html_elements("div.message.default") |>
head(){xml_nodeset (6)}
[1] <div class="message default clearfix" id="message3">\n\n <div class= ...
[2] <div class="message default clearfix" id="message5">\n\n <div class= ...
[3] <div class="message default clearfix" id="message6">\n\n <div class= ...
[4] <div class="message default clearfix" id="message7">\n\n <div class= ...
[5] <div class="message default clearfix" id="message8">\n\n <div class= ...
[6] <div class="message default clearfix" id="message9">\n\n <div class= ...
Уже из этого набора узлов можем доставать все остальное.
archive_1_tbl <- tibble(id = archive_1 |>
html_elements("div.message.default") |>
html_attr("id"),
date = archive_1 |>
html_elements("div.message.default") |>
html_element(".pull_right.date.details") |>
html_attr("title"),
signature = archive_1 |>
html_elements("div.message.default") |>
html_element(".signature.details") |>
html_text2(),
text = archive_1 |>
html_elements("div.message.default") |>
html_element(".text") |>
html_text2()
)
archive_1_tblОбратите внимание, что мы сначала извлекаем нужные элементы при помощи html_elements(), а потом применяем к каждому из них html_element(). Это гарантирует, что в каждом столбце нашей таблицы равное число наблюдений, т.к. функция html_element(), если она не может найти, например, подпись, возвращает NA.
Как вы уже поняли, теперь нам надо проделать то же самое для двух других файлов из архива антиварваров, а значит пришло время превратить наш код в функцию.
scrape_antibarbari <- function(html_file){
messages_tbl <- tibble(
id = html_file |>
html_elements("div.message.default") |>
html_attr("id"),
date = html_file |>
html_elements("div.message.default") |>
html_element(".pull_right.date.details") |>
html_attr("title"),
signature = html_file |>
html_elements("div.message.default") |>
html_element(".signature.details") |>
html_text2(),
text = html_file |>
html_elements("div.message.default") |>
html_element(".text") |>
html_text2()
)
messages_tbl
}
messages_tbl <- map_df(antibarbari_archive, scrape_antibarbari)Вот что у нас получилось.
messages_tbl8.6 Разведывательный анализ
Создатели канала не сразу разрешили подписывать посты, поэтому для первых нескольких десятков подписи не будет. Кроме того, в некоторых постах только фото, для них в столбце text – NA, их можно сразу отсеять.
messages_tbl <- messages_tbl |>
filter(!is.na(text))
messages_tblТакже преобразуем столбец, в котором хранится дата и время. Разделим его на два и выясним, в какое время и день недели чаще всего публикуются сообщения.
messages_tbl2 <- messages_tbl |>
separate(date, into = c("date", "time", NA), sep = " ") |>
mutate(date = dmy(date),
time = hms(time)) |>
mutate(year = year(date),
month = month(date, label = TRUE),
wday = wday(date, label = TRUE),
hour = hour(time),
length = str_count(text, " ") + 1) |>
mutate(wday = factor(wday, levels = c("Sun", "Sat", "Fri", "Thu", "Wed", "Tue", "Mon")))
messages_tbl2summary1 <- messages_tbl2 |>
group_by(year, month) |>
summarise(n = n())
summary1summary2 <- messages_tbl2 |>
group_by(year, hour) |>
summarise(n = n()) |>
mutate(hour = case_when(hour == 0 ~ 24,
.default = hour))
summary2summary3 <- messages_tbl2 |>
group_by(wday) |>
summarise(n = n())
summary3library(gridExtra)
library(grid)
p1 <- summary1 |>
ggplot(aes(month, n, color = as.factor(year), group = year)) +
geom_line(show.legend = FALSE, linewidth = 1.2, alpha = 0.8) +
labs(title = "Число постов в месяц") +
theme(legend.title = element_blank(),
legend.position = c(0.8, 0.3),
title = element_text(face="italic")) +
labs(x = NULL, y = NULL) +
scale_color_viridis_d()
p2 <- summary2 |>
ggplot(aes(hour, n, color = as.factor(year), group = year)) +
geom_line(linewidth = 1.2, alpha = 0.8) +
scale_x_continuous(breaks = seq(1,24,1)) +
labs(x = NULL, y = NULL, title = "Время публикации поста") +
theme(legend.title = element_blank(),
legend.position = "left",
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
title = element_text(face="italic")
) +
coord_polar(start = 0) +
scale_color_viridis_d()
p3 <- summary3 |>
ggplot(aes(wday, n, fill = wday)) +
geom_bar(stat = "identity",
show.legend = FALSE) +
coord_flip() +
labs(x = NULL, y = NULL, title = "Публикации по дням недели") +
theme(title = element_text(face="italic"))
p4 <- messages_tbl2 |>
ggplot(aes(as.factor(year), length, fill = as.factor(year))) +
geom_boxplot(show.legend = FALSE) +
labs(title = "Длина поста по годам") +
labs(x = NULL, y = NULL) +
scale_fill_viridis_d() +
theme(title = element_text(face="italic"))
grid.arrange(p1, p2, p3, p4, nrow = 2,
top = textGrob("Телеграм-канал Antibarbari HSE",
gp=gpar(fontsize=16)),
bottom = textGrob("@Rantiquity",
gp = gpar(fontface = 3, fontsize = 9), hjust = 1, x = 1)) 
8.7 Html таблицы
Если вам повезет, то ваши данные уже будут храниться в HTML-таблице, и их можно будет просто считать из этой таблицы1. Распознать таблицу в браузере обычно несложно: она имеет прямоугольную структуру из строк и столбцов, и ее можно скопировать и вставить в такой инструмент, как Excel.
Таблицы HTML строятся из четырех основных элементов: <table>, <tr> (строка таблицы), <th> (заголовок таблицы) и <td> (данные таблицы). Мы соберем информацию о проектных группах ФГН в 2022-2024 г.
html <- read_html("https://hum.hse.ru/proj/project2022_2024")
my_table <- html |>
html_element(".bordered") |>
html_table()
my_table8.8 Wikisource
Многие тексты доступны на сайте Wikisource.org. Попробуем извлечь все сказки Салтыкова-Щедрина.
url <- "https://ru.wikisource.org/wiki/%D0%9C%D0%B8%D1%85%D0%B0%D0%B8%D0%BB_%D0%95%D0%B2%D0%B3%D1%80%D0%B0%D1%84%D0%BE%D0%B2%D0%B8%D1%87_%D0%A1%D0%B0%D0%BB%D1%82%D1%8B%D0%BA%D0%BE%D0%B2-%D0%A9%D0%B5%D0%B4%D1%80%D0%B8%D0%BD"
html = read_html(url)Для того, чтобы справиться с такой страницей, пригодится Selector Gadget (расширение для Chrome). Вот тут можно посмотреть короткое видео, как его установить. При помощи селектора выбираем нужные уровни.
toc <- html |>
html_elements("ul:nth-child(22) a")
head(toc){xml_nodeset (6)}
[1] <a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D1%82%D1%8C_%D0%BE_%D1%82%D ...
[2] <a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D1%89%D0%B8%D0%BD%D0%B0_(%D ...
[3] <a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D0%BB%D0%B0_%D1%81%D0%BE%D0 ...
[4] <a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_%D0%BF%D0%BE%D0%BC%D0%B5%D1 ...
[5] <a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D0%B4%D1%80%D1%8B%D0%B9_%D0 ...
[6] <a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D1%82%D0%B2%D0%B5%D1%80%D0% ...
Теперь у нас есть список ссылок.
tales <- tibble(
title = toc |>
html_attr("title"),
href = toc |>
html_attr("href")
)Данных о годе публикации под тегом нет; надо подняться на уровень выше:
{xml_nodeset (6)}
[1] <li>\n<a href="/wiki/%D0%9F%D0%BE%D0%B2%D0%B5%D1%81%D1%82%D1%8C_%D0%BE_%D ...
[2] <li>\n<a href="/wiki/%D0%93%D0%BE%D0%B4%D0%BE%D0%B2%D1%89%D0%B8%D0%BD%D0% ...
[3] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%BE%D0%BF%D0%B0%D0%BB%D0%B0_%D1%81%D0 ...
[4] <li>\n<a href="/wiki/%D0%94%D0%B8%D0%BA%D0%B8%D0%B9_%D0%BF%D0%BE%D0%BC%D0 ...
[5] <li>\n<a href="/wiki/%D0%9F%D1%80%D0%B5%D0%BC%D1%83%D0%B4%D1%80%D1%8B%D0% ...
[6] <li>\n<a href="/wiki/%D0%A1%D0%B0%D0%BC%D0%BE%D0%BE%D1%82%D0%B2%D0%B5%D1% ...
toc2 |>
html_text2() [1] "Повесть о том, как один мужик двух генералов прокормил, 1869"
[2] "Годовщина, 1869"
[3] "Пропала совесть, 1869"
[4] "Дикий помещик, 1869"
[5] "Премудрый пискарь, 1883"
[6] "Самоотверженный заяц, 1883"
[7] "Бедный волк, 1883"
[8] "Добродетели и Пороки, 1884"
[9] "Медведь на воеводстве, 1884"
[10] "Обманщик-газетчик и легковерный читатель, 1884"
[11] "Вяленая вобла, 1884"
[12] "Орёл-меценат, 1884"
[13] "Карась-идеалист, 1884"
[14] "Игрушечного дела людишки, 1879, 1886"
[15] "Чижиково горе, 1884"
[16] "Верный Трезор, 1885"
[17] "Недреманное око, конец 1885 или начало 1886"
[18] "Дурак, 1885"
[19] "Соседи, 1885"
[20] "Здравомысленный заяц, 1885"
[21] "Либерал, 1885"
[22] "Баран-непомнящий, 1885"
[23] "Коняга, 1855"
[24] "Кисель, 1855"
[25] "Праздный разговор, 1886"
[26] "Деревенский пожар, 1885"
[27] "Путём-дорогою, 1886"
[28] "Богатырь, 1886"
[29] "Гиена, 1886"
[30] "Приключение с Крамольниковым, 1886"
[31] "Христова ночь, 1886"
[32] "Ворон-челобитчик, 1886"
[33] "Рождественская сказка, 1886"
Соединяем:
tales <- tibble(
title_year = toc2 |>
html_text2(),
href = toc |>
html_attr("href")
)
talesДальше можно достать текст для каждой сказки. Потренируемся на одной. Снова привлекаем Selector Gadget для составления правила.
url_test <- tales |>
filter(row_number() == 1) |>
mutate(link = paste0("https://ru.wikisource.org", href)) |>
pull(link)
text <- read_html(url_test) |>
html_elements(".indent p") |>
html_text2()
text[1][1] "Жили да были два генерала, и так как оба были легкомысленны, то в скором времени, по щучьему велению, по моему хотению, очутились на необитаемом острове."
text[length(text)][1] "Однако, и об мужике не забыли; выслали ему рюмку водки да пятак серебра: веселись, мужичина!"
Первый и последний параграф достали верно; можно обобщать.
tales <- tales |>
mutate(href = paste0("https://ru.wikisource.org", href))urls <- tales |>
pull(href)Функция для извлечения текстов.
get_text <- function(url) {
read_html(url) |>
html_elements(".indent p") |>
html_text2() |>
paste(collapse= " ")
}tales_text <- map(urls, get_text)Несколько сказок не выловились: там другая структура html, но в целом все получилось.
tales_text <- tales_text |>
flatten_chr() |>
as_tibble()
tales <- tales |>
bind_cols(tales_text)talesДальше можно разделить столбец с названием и годом и, например, удалить ссылку, она больше не нужна. Разделить по запятой не получится, т.к. она есть в некоторых названиях.
tales <- tales |>
select(-href) |>
separate(title_year, into = c("title", "year"), sep = -5) |>
mutate(title = str_remove(title, ",$"))talesНедостающие две сказки я не буду пытаться извлечь, но логику вы поняли.
Поздравляем, на этом закончился первый большой раздел нашего курса “Основы работы в R” 🎐. За восемь уроков вы познакомились с основными структурами данных в R, научились собирать и трансформировать данные, строить графики, писать функции и циклы, а также готовить html-отчеты о своих исследованиях. Впереди нас ждут методы анализа текстовых данных.

https://r4ds.hadley.nz/webscraping#tables↩︎